<!DOCTYPE html>
<html >
<head>
<meta charset="utf-8">
<title>Image Processing Video Example</title>
<link href="js_example_style.css" rel="stylesheet" type="text/css" />
<style type="text/css">
.dg {
    text-align: left;
}
.dg .property-name {
    font: 11px Lucida Grande,sans-serif;
    line-height: 27px;
}
.dg.main .close-button {
    font: 11px Lucida Grande,sans-serif;
    line-height: 27px;
}
.cell-top {
    vertical-align: top;
}
</style>
</head>
<body>
<h2>Image Processing Video Example</h2>
<p>
    Open the controls and try different image processing filters.
</p>
<p class="err" id="errorMessage"></p>
<div id="container">
    <table>
        <tr>
            <td></td>
            <td>
                <div>
                    <span>Current Filter: </span><span id="filterName">Pass Through</span>
                </div>
            </td>
            <td>
                <div>Select Filter:</div>
            </td>
            <td></td>
        </tr>
        <tr>
            <td></td>
            <td class="cell-top">
                <canvas id="canvasOutput" width="640" height="480"></canvas>
            </td>
            <td class="cell-top">
                <div id="guiContainer"></div>
            </td>
            <td></td>
        </tr>
    </table>
    <div>
        <video id="videoInput" class="hidden">Your browser does not support the video tag.</video>
    </div>
</div>
<script src="https://webrtc.github.io/adapter/adapter-5.0.4.js" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stats.js/r16/Stats.min.js" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.6.4/dat.gui.min.js" type="text/javascript"></script>
<script src="utils.js" type="text/javascript"></script>
<script type="text/javascript">
let utils = new Utils('errorMessage');

let width = 0;
let height = 0;

let resolution = window.innerWidth < 960 ? 'qvga' : 'vga';

// whether streaming video from the camera.
let streaming = false;

let video = document.getElementById('videoInput');
let vc = null;

let container = document.getElementById('container');

let lastFilter = '';
let src = null;
let dstC1 = null;
let dstC3 = null;
let dstC4 = null;

function startVideoProcessing() {
    src = new cv.Mat(height, width, cv.CV_8UC4);
    dstC1 = new cv.Mat(height, width, cv.CV_8UC1);
    dstC3 = new cv.Mat(height, width, cv.CV_8UC3);
    dstC4 = new cv.Mat(height, width, cv.CV_8UC4);
    requestAnimationFrame(processVideo);
}

function passThrough(src) {
    return src;
}

function gray(src) {
    cv.cvtColor(src, dstC1, cv.COLOR_RGBA2GRAY);
    return dstC1;
}

function hsv(src) {
    cv.cvtColor(src, dstC3, cv.COLOR_RGBA2RGB);
    cv.cvtColor(dstC3, dstC3, cv.COLOR_RGB2HSV);
    return dstC3;
}

function canny(src) {
    cv.cvtColor(src, dstC1, cv.COLOR_RGBA2GRAY);
    cv.Canny(dstC1, dstC1, controls.cannyThreshold1, controls.cannyThreshold2,
             controls.cannyApertureSize, controls.cannyL2Gradient);
    return dstC1;
}

function inRange(src) {
    let lowValue = controls.inRangeLow;
    let lowScalar = new cv.Scalar(lowValue, lowValue, lowValue, 255);
    let highValue = controls.inRangeHigh;
    let highScalar = new cv.Scalar(highValue, highValue, highValue, 255);
    let low = new cv.Mat(height, width, src.type(), lowScalar);
    let high = new cv.Mat(height, width, src.type(), highScalar);
    cv.inRange(src, low, high, dstC1);
    low.delete(); high.delete();
    return dstC1;
}

function threshold(src) {
    cv.threshold(src, dstC4, controls.thresholdValue, 200, cv.THRESH_BINARY);
    return dstC4;
}

function adaptiveThreshold(src) {
    let mat = new cv.Mat(height, width, cv.CV_8U);
    cv.cvtColor(src, mat, cv.COLOR_RGBA2GRAY);
    cv.adaptiveThreshold(mat, dstC1, 200, cv.ADAPTIVE_THRESH_GAUSSIAN_C,
                         cv.THRESH_BINARY, Number(controls.adaptiveBlockSize), 2);
    mat.delete();
    return dstC1;
}

function gaussianBlur(src) {
    cv.GaussianBlur(src, dstC4,
                    {width: controls.gaussianBlurSize, height: controls.gaussianBlurSize},
                    0, 0, cv.BORDER_DEFAULT);
    return dstC4;
}

function bilateralFilter(src) {
    let mat = new cv.Mat(height, width, cv.CV_8UC3);
    cv.cvtColor(src, mat, cv.COLOR_RGBA2RGB);
    cv.bilateralFilter(mat, dstC3, controls.bilateralFilterDiameter, controls.bilateralFilterSigma,
                       controls.bilateralFilterSigma, cv.BORDER_DEFAULT);
    mat.delete();
    return dstC3;
}

function medianBlur(src) {
    cv.medianBlur(src, dstC4, controls.medianBlurSize);
    return dstC4;
}

function sobel(src) {
    let mat = new cv.Mat(height, width, cv.CV_8UC1);
    cv.cvtColor(src, mat, cv.COLOR_RGB2GRAY, 0);
    cv.Sobel(mat, dstC1, cv.CV_8U, 1, 0, controls.sobelSize, 1, 0, cv.BORDER_DEFAULT);
    mat.delete();
    return dstC1;
}

function scharr(src) {
    let mat = new cv.Mat(height, width, cv.CV_8UC1);
    cv.cvtColor(src, mat, cv.COLOR_RGB2GRAY, 0);
    cv.Scharr(mat, dstC1, cv.CV_8U, 1, 0, 1, 0, cv.BORDER_DEFAULT);
    mat.delete();
    return dstC1;
}

function laplacian(src) {
    let mat = new cv.Mat(height, width, cv.CV_8UC1);
    cv.cvtColor(src, mat, cv.COLOR_RGB2GRAY);
    cv.Laplacian(mat, dstC1, cv.CV_8U, controls.laplacianSize, 1, 0, cv.BORDER_DEFAULT);
    mat.delete();
    return dstC1;
}

let contoursColor = [];
for (let i = 0; i < 10000; i++) {
    contoursColor.push([Math.round(Math.random() * 255),
                        Math.round(Math.random() * 255),
                        Math.round(Math.random() * 255), 0]);
}

function contours(src) {
    cv.cvtColor(src, dstC1, cv.COLOR_RGBA2GRAY);
    cv.threshold(dstC1, dstC4, 120, 200, cv.THRESH_BINARY);
    let contours = new cv.MatVector();
    let hierarchy = new cv.Mat();
    cv.findContours(dstC4, contours, hierarchy,
                    Number(controls.contoursMode),
                    Number(controls.contoursMethod), {x: 0, y: 0});
    dstC3.delete();
    dstC3 = cv.Mat.ones(height, width, cv.CV_8UC3);
    for (let i = 0; i<contours.size(); ++i) {
        let color = contoursColor[i];
        cv.drawContours(dstC3, contours, i, color, 1, cv.LINE_8, hierarchy);
    }
    contours.delete(); hierarchy.delete();
    return dstC3;
}

function calcHist(src) {
    cv.cvtColor(src, dstC1, cv.COLOR_RGBA2GRAY);
    let srcVec = new cv.MatVector();
    srcVec.push_back(dstC1);
    let scale = 2;
    let channels = [0];
    let histSize = [src.cols/scale];
    const ranges = [0, 255];
    let hist = new cv.Mat();
    let mask = new cv.Mat();
    let color = new cv.Scalar(0xfb, 0xca, 0x04, 0xff);
    cv.calcHist(srcVec, channels, mask, hist, histSize, ranges);
    let result = cv.minMaxLoc(hist, mask);
    let max = result.maxVal;
    cv.cvtColor(dstC1, dstC4, cv.COLOR_GRAY2RGBA);
    // draw histogram on src
    for (let i = 0; i < histSize[0]; i++) {
        let binVal = hist.data32F[i] * src.rows / max;
        cv.rectangle(dstC4, {x: i * scale, y: src.rows - 1},
                     {x: (i + 1) * scale - 1, y: src.rows - binVal/3}, color, cv.FILLED);
    }
    srcVec.delete();
    mask.delete();
    hist.delete();
    return dstC4;
}

function equalizeHist(src) {
    cv.cvtColor(src, dstC1, cv.COLOR_RGBA2GRAY, 0);
    cv.equalizeHist(dstC1, dstC1);
    return dstC1;
}

let base;

function backprojection(src) {
    if (lastFilter !== 'backprojection') {
        if (base instanceof cv.Mat) {
            base.delete();
        }
        base = src.clone();
        cv.cvtColor(base, base, cv.COLOR_RGB2HSV, 0);
    }
    cv.cvtColor(src, dstC3, cv.COLOR_RGB2HSV, 0);
    let baseVec = new cv.MatVector();
    let targetVec = new cv.MatVector();
    baseVec.push_back(base); targetVec.push_back(dstC3);
    let mask = new cv.Mat();
    let hist = new cv.Mat();
    let channels = [0];
    let histSize = [50];
    let ranges;
    if (controls.backprojectionRangeLow < controls.backprojectionRangeHigh) {
        ranges = [controls.backprojectionRangeLow, controls.backprojectionRangeHigh];
    } else {
        return src;
    }
    cv.calcHist(baseVec, channels, mask, hist, histSize, ranges);
    cv.normalize(hist, hist, 0, 255, cv.NORM_MINMAX);
    cv.calcBackProject(targetVec, channels, hist, dstC1, ranges, 1);
    baseVec.delete();
    targetVec.delete();
    mask.delete();
    hist.delete();
    return dstC1;
}

function erosion(src) {
    let kernelSize = controls.erosionSize;
    let kernel = cv.Mat.ones(kernelSize, kernelSize, cv.CV_8U);
    let color = new cv.Scalar();
    cv.erode(src, dstC4, kernel, {x: -1, y: -1}, 1, Number(controls.erosionBorderType), color);
    kernel.delete();
    return dstC4;
}

function dilation(src) {
    let kernelSize = controls.dilationSize;
    let kernel = cv.Mat.ones(kernelSize, kernelSize, cv.CV_8U);
    let color = new cv.Scalar();
    cv.dilate(src, dstC4, kernel, {x: -1, y: -1}, 1, Number(controls.dilationBorderType), color);
    kernel.delete();
    return dstC4;
}

function morphology(src) {
    let kernelSize = controls.morphologySize;
    let kernel = cv.getStructuringElement(Number(controls.morphologyShape),
                                          {width: kernelSize, height: kernelSize});
    let color = new cv.Scalar();
    let op = Number(controls.morphologyOp);
    let image = src;
    if (op === cv.MORPH_GRADIENT || op === cv.MORPH_TOPHAT || op === cv.MORPH_BLACKHAT) {
        cv.cvtColor(src, dstC3, cv.COLOR_RGBA2RGB);
        image = dstC3;
    }
    cv.morphologyEx(image, dstC4, op, kernel, {x: -1, y: -1}, 1,
                    Number(controls.morphologyBorderType), color);
    kernel.delete();
    return dstC4;
}

function processVideo() {
    if (!streaming) return;
    stats.begin();
    vc.read(src);
    let result;
    switch (controls.filter) {
        case 'passThrough': result = passThrough(src); break;
        case 'gray': result = gray(src); break;
        case 'hsv': result = hsv(src); break;
        case 'canny': result = canny(src); break;
        case 'inRange': result = inRange(src); break;
        case 'threshold': result = threshold(src); break;
        case 'adaptiveThreshold': result = adaptiveThreshold(src); break;
        case 'gaussianBlur': result = gaussianBlur(src); break;
        case 'bilateralFilter': result = bilateralFilter(src); break;
        case 'medianBlur': result = medianBlur(src); break;
        case 'sobel': result = sobel(src); break;
        case 'scharr': result = scharr(src); break;
        case 'laplacian': result = laplacian(src); break;
        case 'contours': result = contours(src); break;
        case 'calcHist': result = calcHist(src); break;
        case 'equalizeHist': result = equalizeHist(src); break;
        case 'backprojection': result = backprojection(src); break;
        case 'erosion': result = erosion(src); break;
        case 'dilation': result = dilation(src); break;
        case 'morphology': result = morphology(src); break;
        default: result = passThrough(src);
    }
    cv.imshow('canvasOutput', result);
    stats.end();
    lastFilter = controls.filter;
    requestAnimationFrame(processVideo);
}

let stats = null;

let filters = {
    'passThrough': 'Pass Through',
    'gray': 'Gray',
    'hsv': 'HSV',
    'canny': 'Canny Edge Detection',
    'inRange': 'In Range',
    'threshold': 'Threshold',
    'adaptiveThreshold': 'Adaptive Threshold',
    'gaussianBlur': 'Gaussian Blurring',
    'medianBlur': 'Median Blurring',
    'bilateralFilter': 'Bilateral Filtering',
    'sobel': 'Sobel Derivatives',
    'scharr': 'Scharr Derivatives',
    'laplacian': 'Laplacian Derivatives',
    'contours': 'Contours',
    'calcHist': 'Calculation',
    'equalizeHist': 'Equalization',
    'backprojection': 'Backprojection',
    'erosion': 'Erosion',
    'dilation': 'Dilation',
    'morphology': 'Morphology',
};

let filterName = document.getElementById('filterName');

let controls;

function initUI() {
    stats = new Stats();
    stats.showPanel(0);
    container.appendChild(stats.domElement);
    stats.domElement.style.position = 'absolute';
    stats.domElement.style.right = '0px';
    stats.domElement.style.top = '0px';

    controls = {
        filter: 'passThrough',
        setFilter: function(filter) {
            this.filter = filter;
            filterName.innerHTML = filters[filter];
        },
        passThrough: function() {
            this.setFilter('passThrough');
        },
        gray: function() {
            this.setFilter('gray');
        },
        hsv: function() {
            this.setFilter('hsv');
        },
        inRange: function() {
            this.setFilter('inRange');
        },
        inRangeLow: 75,
        inRangeHigh: 150,
        threshold: function() {
            this.setFilter('threshold');
        },
        thresholdValue: 100,
        adaptiveThreshold: function() {
            this.setFilter('adaptiveThreshold');
        },
        adaptiveBlockSize: 3,
        gaussianBlur: function() {
            this.setFilter('gaussianBlur');
        },
        gaussianBlurSize: 7,
        medianBlur: function() {
            this.setFilter('medianBlur');
        },
        medianBlurSize: 5,
        bilateralFilter: function() {
            this.setFilter('bilateralFilter');
        },
        bilateralFilterDiameter: 5,
        bilateralFilterSigma: 75,
        sobel: function() {
            this.setFilter('sobel');
        },
        sobelSize: 3,
        scharr: function() {
            this.setFilter('scharr');
        },
        laplacian: function() {
            this.setFilter('laplacian');
        },
        laplacianSize: 3,
        canny: function() {
            this.setFilter('canny');
        },
        cannyThreshold1: 150,
        cannyThreshold2: 300,
        cannyApertureSize: 3,
        cannyL2Gradient: false,
        contours: function() {
            this.setFilter('contours');
        },
        contoursMode: cv.RETR_CCOMP,
        contoursMethod: cv.CHAIN_APPROX_SIMPLE,
        calcHist: function() {
            this.setFilter('calcHist');
        },
        equalizeHist: function() {
            this.setFilter('equalizeHist');
        },
        backprojection: function() {
            this.setFilter('backprojection');
        },
        backprojectionRangeLow: 0,
        backprojectionRangeHigh: 150,
        morphology: function() {
            this.setFilter('morphology');
        },
        morphologyShape: cv.MORPH_RECT,
        morphologyOp: cv.MORPH_ERODE,
        morphologySize: 5,
        morphologyBorderType: cv.BORDER_CONSTANT,
    };

    let gui = new dat.GUI({autoPlace: false});
    let guiContainer = document.getElementById('guiContainer');
    guiContainer.appendChild(gui.domElement);

    let lastFolder = null;
    function closeLastFolder(folder) {
        if (lastFolder != null && lastFolder != folder) {
            lastFolder.close();
        }
        lastFolder = folder;
    }

    gui.add(controls, 'passThrough').name(filters['passThrough']).onChange(function() {
        closeLastFolder(null);
    });

    let colorConversion = gui.addFolder('Color Conversion');
    colorConversion.add(controls, 'gray').name(filters['gray']).onChange(function() {
        closeLastFolder(null);
    });

    colorConversion.add(controls, 'hsv').name(filters['hsv']).onChange(function() {
        closeLastFolder(null);
    });

    let inRange = colorConversion.addFolder(filters['inRange']);
    inRange.domElement.onclick = function() {
        closeLastFolder(inRange);
        controls.inRange();
    };
    inRange.add(controls, 'inRangeLow', 0, 255, 1).name('lower boundary');
    inRange.add(controls, 'inRangeHigh', 0, 255, 1).name('higher boundary');

    // let geometricTransformations = gui.addFolder('Geometric Transformations');
    // TODO

    let thresholding = gui.addFolder('Thresholding');

    let threshold = thresholding.addFolder(filters['threshold']);
    threshold.domElement.onclick = function() {
        closeLastFolder(threshold);
        controls.threshold();
    };
    threshold.add(controls, 'thresholdValue', 0, 200, 1).name('threshold value');

    let adaptiveThreshold = thresholding.addFolder(filters['adaptiveThreshold']);
    adaptiveThreshold.domElement.onclick = function() {
        closeLastFolder(adaptiveThreshold);
        controls.adaptiveThreshold();
    };
    adaptiveThreshold.add(
        controls, 'adaptiveBlockSize', 3, 99, 1).name('block size').onChange(
        function(value) {
            if (value % 2 === 0) controls.adaptiveBlockSize = value + 1;
        });

    let smoothing = gui.addFolder('Smoothing');

    let gaussianBlur = smoothing.addFolder(filters['gaussianBlur']);
    gaussianBlur.domElement.onclick = function() {
        closeLastFolder(gaussianBlur);
        controls.gaussianBlur();
    };
    gaussianBlur.add(
        controls, 'gaussianBlurSize', 7, 99, 1).name('kernel size').onChange(
        function(value) {
            if (value % 2 === 0) controls.gaussianBlurSize = value + 1;
        });

    let medianBlur = smoothing.addFolder(filters['medianBlur']);
    medianBlur.domElement.onclick = function() {
        closeLastFolder(medianBlur);
        controls.medianBlur();
    };
    medianBlur.add(
        controls, 'medianBlurSize', 3, 99, 1).name('kernel size').onChange(
        function(value) {
            if (value % 2 === 0) controls.medianBlurSize = value + 1;
        });

    let bilateralFilter = smoothing.addFolder(filters['bilateralFilter']);
    bilateralFilter.domElement.onclick = function() {
        closeLastFolder(bilateralFilter);
        controls.bilateralFilter();
    };
    bilateralFilter.add(controls, 'bilateralFilterDiameter', 1, 15, 1).name('diameter');
    bilateralFilter.add(controls, 'bilateralFilterSigma', 1, 255, 1).name('sigma');

    let morphology = gui.addFolder('Morphology');
    morphology.domElement.onclick = function() {
        closeLastFolder(morphology);
        controls.morphology();
    };
    morphology.add(
        controls, 'morphologyOp',
        {'MORPH_ERODE': cv.MORPH_ERODE,
         'MORPH_DILATE': cv.MORPH_DILATE,
         'MORPH_OPEN ': cv.MORPH_OPEN,
         'MORPH_CLOSE': cv.MORPH_CLOSE,
         'MORPH_GRADIENT': cv.MORPH_GRADIENT,
         'MORPH_TOPHAT': cv.MORPH_TOPHAT,
         'MORPH_BLACKHAT': cv.MORPH_BLACKHAT}).name('operation');
    morphology.add(
        controls, 'morphologyShape',
        {'MORPH_RECT': cv.MORPH_RECT,
         'MORPH_CROSS': cv.MORPH_CROSS,
         'MORPH_ELLIPSE': cv.MORPH_ELLIPSE}).name('shape');
    morphology.add(
        controls, 'morphologySize', 1, 15, 1).name('kernel size').onChange(
        function(value) {
            if (value % 2 === 0) controls.morphologySize = value + 1;
        });
    morphology.add(
        controls, 'morphologyBorderType',
        {'BORDER_CONSTANT': cv.BORDER_CONSTANT,
         'BORDER_REPLICATE': cv.BORDER_REPLICATE,
         'BORDER_REFLECT': cv.BORDER_REFLECT,
         'BORDER_REFLECT_101': cv.BORDER_REFLECT_101}).name('boarder type');

    let gradients = gui.addFolder('Gradients');
    let sobel = gradients.addFolder(filters['sobel']);
    sobel.domElement.onclick = function() {
        closeLastFolder(sobel);
        controls.sobel();
    };
    sobel.add(controls, 'sobelSize', 3, 19, 1).name('kernel size').onChange(function(value) {
        if (value % 2 === 0) controls.sobelSize = value + 1;
    });

    gradients.add(controls, 'scharr').name(filters['scharr']).onChange(function() {
        closeLastFolder(null);
    });

    let laplacian = gradients.addFolder(filters['laplacian']);
    laplacian.domElement.onclick = function() {
        closeLastFolder(laplacian);
        controls.laplacian();
    };
    laplacian.add(
        controls, 'laplacianSize', 1, 19, 1).name('kernel size').onChange(
        function(value) {
            if (value % 2 === 0) controls.laplacianSize = value + 1;
        });

    let canny = gui.addFolder(filters['canny']);
    canny.domElement.onclick = function() {
        closeLastFolder(canny);
        controls.canny();
    };
    canny.add(controls, 'cannyThreshold1', 1, 500, 1).name('threshold1');
    canny.add(controls, 'cannyThreshold2', 1, 500, 1).name('threshold2');
    canny.add(controls, 'cannyApertureSize', 3, 7, 1).name('aperture size').onChange(
        function(value) {
            if (value % 2 === 0) controls.cannyApertureSize = value + 1;
        });
    canny.add(controls, 'cannyL2Gradient').name('l2 gradient');

    let contours = gui.addFolder(filters['contours']);
    contours.domElement.onclick = function() {
        closeLastFolder(contours);
        controls.contours();
    };
    contours.add(
        controls, 'contoursMode',
        {'RETR_EXTERNAL': cv.RETR_EXTERNAL,
         'RETR_LIST': cv.RETR_LIST,
         'RETR_CCOMP': cv.RETR_CCOMP,
         'RETR_TREE': cv.RETR_TREE}).name('mode');
    contours.add(
        controls, 'contoursMethod',
        {'CHAIN_APPROX_NONE': cv.CHAIN_APPROX_NONE,
         'CHAIN_APPROX_SIMPLE': cv.CHAIN_APPROX_SIMPLE,
         'CHAIN_APPROX_TC89_L1': cv.CHAIN_APPROX_TC89_L1,
         'CHAIN_APPROX_TC89_KCOS': cv.CHAIN_APPROX_TC89_KCOS}).name('method');

    let histograms = gui.addFolder('Histograms');
    histograms.add(controls, 'calcHist').name(filters['calcHist']).onChange(function() {
        closeLastFolder(null);
    });
    histograms.add(controls, 'equalizeHist').name(filters['equalizeHist']).onChange(function() {
        closeLastFolder(null);
    });

    let backprojection = histograms.addFolder(filters['backprojection']);
    backprojection.domElement.onclick = function() {
        closeLastFolder(backprojection);
        controls.backprojection();
    };
    backprojection.add(controls, 'backprojectionRangeLow', 0, 255, 1).name('range low');
    backprojection.add(controls, 'backprojectionRangeHigh', 0, 255, 1).name('range high');
}

function startCamera() {
    if (!streaming) {
        utils.clearError();
        utils.startCamera(resolution, onVideoStarted, 'videoInput');
    } else {
        utils.stopCamera();
        onVideoStopped();
    }
}

function onVideoStarted() {
    height = video.videoHeight;
    width = video.videoWidth;
    video.setAttribute('width', width);
    video.setAttribute('height', height);
    streaming = true;
    vc = new cv.VideoCapture(video);
    startVideoProcessing();
}

function stopVideoProcessing() {
    if (src != null && !src.isDeleted()) src.delete();
    if (dstC1 != null && !dstC1.isDeleted()) dstC1.delete();
    if (dstC3 != null && !dstC3.isDeleted()) dstC3.delete();
    if (dstC4 != null && !dstC4.isDeleted()) dstC4.delete();
}

function onVideoStopped() {
    if (!streaming) return;
    stopVideoProcessing();
    document.getElementById('canvasOutput').getContext('2d').clearRect(0, 0, width, height);
    streaming = false;
}

utils.loadOpenCv(() => {
    initUI();
    startCamera();
});
</script>
</body>
</html>
